#! c:\perl\bin\perl.exe
#-----------------------------------------------------------------------------
# macrip - MacRipper, CLI version
# Use this utility to run a plugins file or a single plugin against
# a PLIST or SQLITE file (or whatever...).
#
# Output goes to STDOUT
#
# Change History
#   20111227 % [fpi] created
#   20120207 * [fpi] modified plugin template, added some helpers
#   20120530 * [fpi] totally refactoring (many times) started
#   20120607 * [fpi] first release v1.0
#
# TODO
#   add support for TEXT files
#
# copyright 2011 Francesco Picasso <francesco.picasso@gmail.com>
#-----------------------------------------------------------------------------

use strict;
use warnings;

use DBI;
use File::stat;
use Fcntl;
use Getopt::Long qw( :config no_ignore_case );
use Time::Local;
use Time::Piece;
use XPlist;

my %config;
Getopt::Long::Configure( "prefix_pattern=(-|\/)" );
GetOptions( \%config, qw( Single|S=s Mount|M=s Tsk|T=s offset|o=n Flat|F=s rip|r=s RIP|R=s chroot=s user=s timezone|z=s list|l LIST|L help|?|h ) );

my $VERSION = "1.0.20120607";

use constant {
    FLAT    => 'FLAT',
    MOUNT   => 'MOUNT',
    SINGLE  => 'SINGLE',
    TSK     => 'TSK'
};

#-----------------------------------------------------------------------------
# Initial checks

my @path;
my $MODE;
my $SEP;
my $WINOS;
my $str = $0;
if ($^O eq "MSWin32") {
    @path = split(/\\/,$0);
    $SEP = '\\';
    $WINOS = 1;
}
else {
    @path = split(/\//,$0);
    $SEP = '/';
    $WINOS = 0;
}
$str =~ s/($path[scalar(@path) - 1])//;
my $pluginDir = $str."plugins$SEP";

if ($config{help} || !%config) {
    usage();
    exit;
}

#-----------------------------------------------------------------------------
# Plugins list or LIST

if ( $config{list} or $config{LIST} )
{
    opendir( DIR, $pluginDir ) || die "Could not open $pluginDir: $!\n";
    my @plugins = readdir(DIR);
    closedir(DIR);

    my $count = 1;
    if ($config{LIST}) {
    	print "Plugin,Version,Description,ApplyTo,".
              "SourceType,SourceFile,SourcePath\n";
    }
    foreach my $p (@plugins) {
        next unless ($p =~ m/\.pl$/);
        my $pkg = (split(/\./,$p,2))[0];
        $p = $pluginDir.$p;
        eval {
            require $p;
            my $version     = $pkg->getVersion();
            my $description = $pkg->getDescription();
            my $applyto     = $pkg->getApplyTo();
            my $srctype		= $pkg->getSourceType();
            my $srcpath     = $pkg->getSourcePath();
            my $srcfile     = $pkg->getSourceFile();            
            if ($config{LIST}) {
                print "$pkg,$version,$description,$applyto,$srctype,".
                      "$srcpath,$srcfile\n";
            }
            else {
                print "$count. $pkg v.$version\n   $description ($applyto)\n";
                print "   Source Type: $srctype\n";
               	print "   Source File: $srcfile\n";
               	print "   Source Path: $srcpath\n";
                $count++;
            }
        };
        print "Error: $@\n" if ($@);
    }
    exit;
}

#-----------------------------------------------------------------------------

logMsg( "MacRipper $VERSION begin" );

#-----------------------------------------------------------------------------
# SINGLE mode

if ( $config{Single} )
{
    if ( not $config{rip} ) { print "Missing [-r|rip] parameter!\n"; exit -1; }
    if ( $config{Flat} or $config{Mount} or $config{Tsk} )
    { print "Mixed modalities not allowed!\n"; exit -1; }
    if ( $config{offset} or $config{chroot} or $config{user} or $config{RIP} )
    { print "SINGLE mode doesn't support [RIP|offset|chroot|user]!\n";exit -1;}
    
    $MODE = SINGLE;

    my $pluginFile = '';
    my $filePath = '';
	eval {
        $filePath = $config{Single};
        die "You must enter a source file path/name.\n" if ($filePath eq "");
        die $filePath." not found.\n" unless (-e $filePath and ! -d $filePath);

        my $plugin = $config{rip};
        $pluginFile = $pluginDir.$config{rip}."\.pl";
        die $pluginFile." not found.\n" unless (-e $pluginFile);
    
        executePlugin( undef, undef,
                       $filePath, 
                       $plugin, $pluginFile,
                       $config{timezone}, undef, undef );
	};
	if ($@) { logMsg("Error (source:$filePath)(plugin:$pluginFile): ".$@); }
}

#-----------------------------------------------------------------------------
# MOUNT mode

if ( $config{Mount} )
{
    if ( (!$config{rip} and !$config{RIP}) or ($config{rip} and $config{RIP}) )
    { print "Missing [r|rip] or [R|Rip] parameters!\n"; exit -1; }
    if ( $config{Flat} or $config{Single} or $config{Tsk} )
    { print "Mixed modalities not allowed!\n"; exit -1; }
    if ( $config{offset} or $config{chroot} )
    { print "MOUNT mode does not support [offset|chroot]!\n"; exit -1; }
    
    $MODE = MOUNT;
    $config{Mount} =~ s/\/$//;

    my %plugins;
    if ( $config{rip} ) { $plugins{1} = $config{rip}; }
    else {
        %plugins = parseProfile( $config{RIP} );
        die "Error parsing Profile '$config{RIP}'\n" unless %plugins;
        logMsg("Parsed Profile file '$config{RIP}'");
    }
    
    die "MOUNT mode does not support [chroot]!\n" if ( $config{chroot} );
    $config{chroot} = $config{Mount};
    
    foreach my $i ( keys %plugins )
    {
        logMsg( "PLUGIN $i - ".$plugins{$i} );
        eval {
            my $pluginFile = $pluginDir.$plugins{$i}."\.pl";
            executePlugin( undef, undef,
                           undef, 
                           $plugins{$i}, $pluginFile,
                           $config{timezone}, $config{chroot}, $config{user} );
        };
        logMsg( "Error in MOUNT mode: ".$@ ) if ($@);
        logMsg( $plugins{$i}." complete" );
        rptMsg( "-" x 79 );
    }
}

#-----------------------------------------------------------------------------
# TSK mode

if ( $config{Tsk} )
{
    if ( (!$config{rip} and !$config{RIP}) or ($config{rip} and $config{RIP}) )
    { print "Missing or invalid [r|rip] or [R|Rip] parameters!\n"; exit -1; }
    if ( $config{Flat} or $config{Single} or $config{Mount} )
    { print "Mixed modalities not allowed!\n"; exit -1; }
    logMsg( "TSK mode" );
    
    $MODE = TSK;

    my $offset = undef;
    $offset = "-o ".$config{offset} if ( $config{offset} );

    my $image = $config{Tsk};
    die $image." not found.\n" unless (-e $image);

    my %plugins;
    if ( $config{rip} ) { $plugins{1} = $config{rip}; }
    else {
        %plugins = parseProfile( $config{RIP} );
        die "Error parsing Profile '$config{RIP}'\n" unless %plugins;
        logMsg("Parsed Profile file '$config{RIP}'");
    }

    foreach my $i ( keys %plugins )
    {
        logMsg( "PLUGIN $i - ".$plugins{$i} );
        eval {
            my $pluginFile = $pluginDir.$plugins{$i}."\.pl";
            executePlugin( $image, $offset,
                           undef, 
                           $plugins{$i}, $pluginFile,
                           $config{timezone}, $config{chroot}, $config{user} );
        };
        logMsg( "Error TSK mode: ".$@ ) if ($@);
        logMsg( $plugins{$i}." complete" );
        rptMsg( "-" x 79 );
    }
}

#-----------------------------------------------------------------------------
# FLAT mode

if ( $config{Flat} )
{
    if ( (!$config{rip} and !$config{RIP}) or ($config{rip} and $config{RIP}) )
    { print "Missing [r|rip] or [R|Rip] parameters!\n"; exit -1; }
    if ( $config{Tsk} or $config{Single} or $config{Mount} )
    { print "Mixed modalities not allowed!\n"; exit -1; }
    if ( $config{offset} or $config{chroot} or $config{user} )
    { print "FLAT mode does not support [offset|chroot|user]!\n"; exit -1; }
    
    $MODE = FLAT;

    my %plugins;
    if ( $config{rip} ) { $plugins{1} = $config{rip}; }
    else {
        %plugins = parseProfile( $config{RIP} );
        die "Error parsing Profile '$config{RIP}'\n" unless %plugins;
        logMsg("Parsed Profile file '$config{RIP}'");
    }
    
    foreach my $i ( keys %plugins )
    {
        logMsg( "PLUGIN $i - ".$plugins{$i} );
        eval {
            my $pluginFile = $pluginDir.$plugins{$i}."\.pl";
            require $pluginFile;
            
            my $fileName = $plugins{$i}->getSourceFile();
            my $filePath = $config{Flat}.$SEP.$fileName;

            executePlugin( undef, undef,
                           $filePath, 
                           $plugins{$i}, $pluginFile,
                           $config{timezone}, undef, undef );
        };
        logMsg( "Error in FLAT mode: ".$@ ) if ($@);
        logMsg( $plugins{$i}." complete" );
        rptMsg( "-" x 79 );
    }
}

#-----------------------------------------------------------------------------

logMsg( "MacRipper $VERSION end" );

#-----------------------------------------------------------------------------
# MacRipper helpers
#-----------------------------------------------------------------------------

sub executePlugin
{
    my $image = shift;
    my $offset = shift;
    my $filePath = shift;
    my $plugin = shift;
    my $pluginFile = shift;
    my $timezone = shift;
    my $chroot = shift;
    my $user = shift;
    
    my $fileLocation = undef;
    my $blob = undef;

    require $pluginFile;
    
    if ( $MODE eq SINGLE or $MODE eq FLAT ) {
        die "SINGLE|FLAT modes do require filePath!" if not $filePath;
        $fileLocation = $filePath;
    }
    elsif ( $MODE eq TSK or $MODE eq MOUNT ) {
        die "TSK|MOUNT modes do not require filePath!" if $filePath;
        $fileLocation = getFileLocation( $plugin, $chroot, $user );
    }
    else { die "Unmanaged modality!?!?"; }
    die "File Location undefined!" if not defined $fileLocation;
    
    reportPluginHeader( $plugin, $fileLocation );
    my $plut = lc($plugin->getSourceType());
    
    eval {
        my $data = undef;
                
        if ( $MODE eq TSK and $plut ne 'empty' ) {
            reportSourceFileTimeTSK($image, $offset, $fileLocation, $timezone);
            $data = getSourceFileTSK( $image, $offset, $fileLocation );
        }
        elsif ( $plut ne 'empty' ) {
            die "Source '$fileLocation' not found!" if (not -e $fileLocation);
            die "Source '$fileLocation' invalid!" if (-d $fileLocation);
            reportSourceFileTime( $fileLocation, $timezone );
            $data = getSourceFile( $fileLocation );
        }
        else { die "executePlugin() internal unmanged code flow"; }
        
        if ( $plut eq 'empty' ) {
            $blob = undef;
        }
        elsif ( $plut eq 'plist' ) {
           	$blob = XPlist::parse_plist( $data );
            $blob = $blob->as_perl;
        }
        elsif ( $plut eq 'blob' ) {
            $blob = $data;
        }
        elsif ( $plut eq 'sqlite' ) {
            if ( defined $image ) {
                $data = $blob = undef;
                die 'Cannot manage DBI connection to file in memory!';
            }
            else {
                $blob=DBI->connect("dbi:SQLite:dbname=$fileLocation", "", "");
            }
        }
        else { die "Invalid plugin type $plut\n"; }
    };
    if ($@) {
        rptMsg("Error: ".$@);
        logMsg("Error: ".$@);
        $blob = undef;
        return;
    }

    $plugin->pluginmain( $blob, $timezone );
    $blob->disconnect() if ( $plut eq 'sqlite' and $blob );
    $blob = undef;
}

#-----------------------------------------------------------------------------

sub getSourceFileTSK
{
    my $image = shift;
    my $offset = shift;
    my $fileloc = shift;
    
    $offset = '' unless $offset;
    
    my $cmd = "ifind $offset -n \"$fileloc\" \"$image\"";
    my $inode = `$cmd`; chomp($inode);
    
    die "file '$fileloc' not found in image" if (not $inode =~ m/^[0-9]+$/);
    $inode = int($inode);
    
    my $data;
    $cmd = "icat $offset \"$image\" $inode |";
    open(ICAT, $cmd) or die "TSK can't fork: $!";
    binmode(ICAT); while(<ICAT>) { $data .= $_; }
    close ICAT or die "TSK error executing command '$cmd': [$!] [$?]";
    
    return $data;
}

#-----------------------------------------------------------------------------

sub getSourceFile
{
    my $fileloc = shift;
    my $data;
    
    die "Source file '$fileloc' not found!" if ( not -e $fileloc );
    $data = undef if fastReadFile( $fileloc, \$data );
    return $data;
}

#-----------------------------------------------------------------------------

sub reportPluginHeader
{
    my $plugin = shift;
    my $filePath = shift;
    
    rptMsg("$plugin v.".$plugin->getVersion());
    rptMsg("".$plugin->getDescription());
    rptMsg( "source: ".$plugin->getSourceFile());
    rptMsg( "type: ".$plugin->getSourceType());
    rptMsg( "path: ".$plugin->getSourcePath());
    rptMsg("");
    rptMsg("Applied to file: ".$filePath);
}

#-----------------------------------------------------------------------------

sub getFileLocation
{
    my $plugin = shift;
    my $chroot = shift;
    my $user = shift;
    my $filePath = $plugin->getSourcePath().$plugin->getSourceFile();
    die "Invalid path '$filePath' from plugin '$plugin'" if not $filePath;
    $filePath = $chroot.$filePath if $chroot;
    $filePath =~ s/\?<USER>\?/$user/ if $user;
    die "Plugin's path needs -user specification!"
        if ( $filePath =~ m/\?<USER>\?/ and not $user );
    return $filePath;
}

#-----------------------------------------------------------------------------

sub reportSourceFileTimeTSK
{
    my $image = shift;
    my $offset = shift;
    my $fileloc = shift;
    my $timezone = shift;
    
    my ($m,$a,$c,$b) = getFileTimeTSK( $image, $offset, $fileloc, $timezone );
 
    $m = '<unable to get>' unless $m;
    $a = '<unable to get>' unless $a;
    $c = '<unable to get>' unless $c;
    $b = '<unable to get>' unless $b;
    
    rptMsg( sprintf( "%s %s (UTC)", 'Modified:', $m ) );
    rptMsg( sprintf( "%s %s (UTC)", 'Accessed:', $a ) );
    rptMsg( sprintf( "%s %s (UTC)", 'Created :', $b ) );
    rptMsg( sprintf( "%s %s (UTC)", 'Changed :', $c ) );
    rptMsg("");
}

#-----------------------------------------------------------------------------

sub reportSourceFileTime
{
    my $fileloc = shift;
    my $timezone = shift;
    
    my ($m,$a,$c,$b) = getFileTime( $fileloc, $timezone );
    
    $m = '<unable to get>' unless $m;

    rptMsg("Modified: $m (UTC)");
    rptMsg("");
}

#-----------------------------------------------------------------------------

sub getFileTimeTSK
{
    my $image = shift;
    my $offset = shift;
    my $fileloc = shift;
    my $timezone = shift;
    
    my $m; my $a; my $c; my $b;
    $m = $a = $c = $b = undef;
    
    $offset = '' unless $offset;
    if (defined $timezone) { $timezone = "-z $timezone"; }
    else { $timezone = ''; }
    
    my $cmd = "ifind $offset -n \"$fileloc\" \"$image\"";
    my $inode = `$cmd`; chomp($inode);
    
    die "file '$fileloc' not found in image" if (not $inode =~ m/^[0-9]+$/);
    $inode = int($inode);

    $cmd = "istat $offset $timezone \"$image\" $inode";
    my @desc = `$cmd`;
    foreach my $line (@desc) {
        chomp($line);
        # SleuthKit istat returns localtime, it's needed UTC conversion
        if ( $line =~ m/^Created:/ ) {
            my ($s1,$s2) = split("\t", $line);
            $b = Time::Piece->strptime( $s2, '%a %b %d %H:%M:%S %Y' );
            $b -= $b->localtime->tzoffset;
            next;
        }
        if ( $line =~ m/^Content Modified:/ ) {
            my ($s1,$s2) = split("\t", $line);
            $m = Time::Piece->strptime( $s2, '%a %b %d %H:%M:%S %Y' );
            $m -= $m->localtime->tzoffset;
            next;
        }
        if ( $line =~ m/^Attributes Modified:/ ) {
            my ($s1,$s2) = split("\t", $line);
            $c = Time::Piece->strptime( $s2, '%a %b %d %H:%M:%S %Y' );
            $c -= $c->localtime->tzoffset;
            next;
        }
        if ( $line =~ m/^Accessed:/ ) {
            my ($s1,$s2) = split("\t", $line);
            $a = Time::Piece->strptime( $s2, '%a %b %d %H:%M:%S %Y' );
            $a -= $a->localtime->tzoffset;
            next;
        }
    }
    return ($m,$a,$c,$b);
}

#-----------------------------------------------------------------------------

sub getFileTime
{
    my $fileloc = shift;
    my $timezone = shift;
    
    my $m; my $a; my $c; my $b;
    $m = $a = $c = $b = undef;
    
    $timezone = '' unless $timezone;
    
    if ( -e $fileloc ) {      
        $m = gmtime(stat($fileloc)->mtime);
        $a = gmtime(stat($fileloc)->atime);
        $b = gmtime(stat($fileloc)->ctime);
    }
    return ($m,$a,$c,$b);
}

#-----------------------------------------------------------------------------

sub logMsg {
    binmode STDERR,":utf8";
	print STDERR $_[0]."\n";
}

#-----------------------------------------------------------------------------

sub rptMsg {
	binmode STDOUT,":utf8";
	print $_[0]."\n";
}

#-----------------------------------------------------------------------------

sub parseProfile
{
	my %plugins;
	my $file = $_[0];
	my $profile = $pluginDir.$file;
	if ( -e $profile ) {
		open( FH, "<" , $profile ) or die "Failed to open Profile";
		my $count = 1;
		while( <FH> ) {
			chomp;
			next if ( $_ =~ m/^#/ || $_ =~ m/^\s+$/ );
			next if ( $_ eq "" );
            if ( $_ =~ m/^-chroot/ ) {
                logMsg( "Found '-chroot' specification in profile" );
                next if $config{chroot};
                $_ =~ s/^-chroot[ ]*//;
                $config{chroot} = $_;
                logMsg( "Set 'chroot' to '$config{chroot}'" );
                next;
            }
            if ( $_ =~ m/^-user/ ) {
                logMsg( "Found '-user' specification in profile" );
                next if $config{user};
                $_ =~ s/^-user[ ]*//;
                $config{user} = $_;
                logMsg( "Set 'user' to '$config{user}'" );
                next;
            }
			$_ =~ s/^\s+//;
			$_ =~ s/\s+$//;
			$plugins{$count++} = $_;
		}
		close(FH);
		logMsg( "Added ".($count-1)." plugins to list" );
	}
	else { logMsg( "Profile not found" ); }
	return %plugins;
}

#-----------------------------------------------------------------------------

sub fastReadFile
{
    my $filePath = shift;
    my $bufRef = shift;

    local( *FH ) ;
    sysopen( FH, $filePath, O_RDONLY | O_BINARY ) or return -1;

    my $size_left = -s FH;

    while( $size_left > 0 ) {
        my $read_cnt = sysread( FH, ${$bufRef}, $size_left, length ${$bufRef} ) ;
        unless( $read_cnt ) { last; }
    	$size_left -= $read_cnt ;
    }
	return 0;
}

#-----------------------------------------------------------------------------

sub usage
{
	print<< "EOT";
macrip v.$VERSION - CLI MacRipper tool
Parse Apple PLIST/SQLITE/blob files, using either a single module, or a plugins
file (profile). The type of files, filenames and filepaths are determined by
the plugin invoked, but they can be altered with [-chroot] and/or [-user]
parameters. MacRipper has 4 operational modes: FLAT, MOUNT, SINGLE, TSK.
All plugins must be located in the \"plugins\" directory.
Parameters:

  -F|Flat   [directory]..........directory containing source files (need -OS)
  -M|Mount  [mount point]........root mount point (need -OS)
  -S|Single [source file]........source file to parse
  -T|Tsk    [bitstream image]....bitstream image file (need -OS)
  -o        [offset to volume]...offset to the volume in sectors (TSK)
  -r|rip    [plugin file]........rip using this module (all modes)
  -R|RIP    [profile file].......rip using the profile file (MOUNT,TSK,FLAT)
  -chroot   [mount point]........the path to root mount point (TSK)
  -user     [user name]..........the name of the user parsed (MOUNT, TSK)
  -z        [timezone]...........the timezone of the original evidence
  -l ............................list all plugins
  -L ............................list all plugins in CSV format
  -h.............................help (print this information)

NOTE -chroot: the "mount point" will be added in front of the source file paths
NOTE -user: the "user name" will sobstitute the special string "?<USER>?"
            in source file paths
NOTE on Profile: profiles can specify the -chroot and -user parameters.
                 The command line values, if specified, will take precedence.
                 MacRipper will use the Profile chroot|user settings on the
                 plugins following such settings.

{FLAT mode}
Ex: #>macrip.pl -F X:\\samples -r plugin
    MacRipper extracts the source file names from the plugin then it searches
    in the "FLAT" directory specified for those file names, finally executing
    the plugin.
Ex: #>macrip.pl -F X:\\samples -R profile
    MacRipper extracts the source file names from the plugins specified in
    profile and it will search in the "FLAT" directory specified for those
    file names, finally executing the plugins in the profile.

{MOUNT mode}
Ex: #>macrip.pl -M /mount/point/ -r plugin
    MacRipper extracts the source file names and paths from the plugin then
    it searches  for those source files, finally executing the plugin
Ex: #>macrip.pl -M /mount/point/ -R profile
    The same as the previous example but executed on every plugin specified
    in the profile
Ex: #>macrip.pl -M /mount/point/ -r plugin -user fpi
    MacRipper extracts the source file names and paths from the plugin,
    substituting - where specified - the "?<USER>?" string in paths,
    then it searches for those source files, finally executing the plugin.
Ex: #>macrip.pl -M /mount/point/ -R profile -user fpi
    The same as the previous example but executed on every plugin specified
    in the profile
Ex: #>macrip.pl -M / [-r plugin][-R profile][-user]
    Using and empty string in MOUNT mode it's useuful when ripping on a LIVE
    system.

{TSK mode}
The TSK mode needs SleuthKit in PATH, since it relies on its services to access
the bitstream image.
Ex: #>macrip.pl -T bitstream.dd -r plugin
    MacRipper extracts the source file names and paths from the plugin then it
    searches them in the bitstream image using SleuthKit facilities, finally
    executing plugin
Ex: #>macrip.pl -T bitstream.dd -R profile
    The same as the previous example but executed on every plugin specified
    in the profile
Ex: #>macrip.pl -T bitstream.dd -o 63 -r plugin
    MacRipper will pass the -o value specified to Sleuthkit to properly access
    the partition
Ex: #>macrip.pl -T bitstream.dd -r plugin -chroot private -user fpi
    MacRipper extracts the source file names and paths from the plugin,
    adding the '/private'  to paths and substituting - where specified - the
    "?<USER>?" string in paths, then it searches  them in the bitstream image
    using SleuthKit facilities, finally executing plugin
Ex: #>macrip.pl -T bitstream.dd -R profile -chroot private -user fpi
    The same as the previous example but executed on every plugin specified
    in the profile

{SINGLE mode}
Ex: #>macrip.pl -S X:\\samples\\com.apple.Accessibility.plist -r plugin
    MacRipper executes the plugin on the source file specified
Ex: #>macrip.pl -S "com.apple.Accessibility.plist,preferences.plist" -r plugin
    MacRipper executes the plugin on the source files specified and separated
    by comma (the order will be preserved, so check the plugin used to
    understand what order to use)

copyright 2011 Francesco Picasso
EOT
}

#-----------------------------------------------------------------------------
# PLIST Plugin helpers
#-----------------------------------------------------------------------------

sub getPlistKeyValue
{
    my $tref = shift; my $key = shift;
    return undef if not $tref;
    return undef if ( not defined $tref->{$key} );
    return $tref->{$key};
}

#-----------------------------------------------------------------------------

sub rptPlistAllKeyValInDict
{
    my $tref = shift; my $tab = shift; my $align = shift;
    my $key; my $val;

    foreach $key ( keys %$tref ) {
        $val = $tref->{$key};
        ::rptMsg( sprintf( $tab."$align = %s", $key, $val ) );
    }
}

#-----------------------------------------------------------------------------

sub macTimeToUTC
{
    my $mactime = shift;
    $mactime += 978307200;
    my $utc = gmtime($mactime)." (UTC)";
    return $utc;
}

#-----------------------------------------------------------------------------
